GitHub Actionsを使ってSupabaseにObsidianの記事をアップロードする
FreshでObsidian Publishライクなブログを目指すより。
やること
- GitHub Actionsを使ってSupabaseにObsidianの記事をアップロードする。
- 実はObsidianから直接アップロードもできる
- GitHub上を正のものとしたいから GitHub Actionsを通すことにしておく。
手順
- Supabaseに登録する
- 料金FREEの範囲内であれば嬉しい。ダメなら$25/月。
- テーブルを考える
upload_to_supabase.ts
ファイルを作り、ローカルでSupabaseにアップロードできるようにしてみる- GitHub Actionsを使ってSupabaseにアップロードする(3を実行)
テーブル
ブログ記事のテーブル。基本的にコレまでのブログの構造を基に、ノートの値もカバーできるようにした。
CREATE TABLE articles (
id SERIAL PRIMARY KEY,
slug TEXT UNIQUE NOT NULL, -- 記事の識別子
title TEXT NOT NULL, -- 記事のタイトル
content TEXT NOT NULL, -- Markdown の内容
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
private BOOLEAN DEFAULT false, -- 非公開フラグ
aliases TEXT[] DEFAULT ARRAY[]::TEXT[], -- 別名
url TEXT DEFAULT '', -- 外部ソースのリンク
cover_image TEXT DEFAULT NULL -- カバー画像
);
ぽちぽち作る
- RLSとは
- aliases が作れない
Table Editor
じゃなくてSQL Editor
を使えば良さそう。- 1回
Table Editor
で作ったものを削除して、SQL Editor
で実行。
Table Editor
側で見てもできている。
GitHub Actionsを使ってSupabaseにアップロードする
スクリプトを書く。
アップロードしたい保管庫の .deno/upload_to_supabase.ts
に配置。
.deno
├── deno.json
└── upload_to_supabase.ts
{
"compilerOptions": {
"strict": true
},
"tasks": {
"upload": "deno run --allow-read --allow-net --allow-env .deno/upload_to_supabase.ts"
},
"imports": {},
"lint": {
"rules": {
"recommended": true
}
}
}
upload_to_supabase.ts
は最後に書く。
import "https://deno.land/std@0.224.0/dotenv/load.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js";
import { parse } from "https://deno.land/std/yaml/mod.ts";
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const SUPABASE_KEY = Deno.env.get("SUPABASE_KEY")!;
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
// アップロード対象フォルダ
const uploadFolders = ["Notes", "100_DailyNote"];
interface Metadata {
created_at: string;
updated_at: string;
private: boolean;
aliases: string[];
url: string;
cover_image?: string;
}
// Markdown のメタデータと内容をパース
function parseMarkdown(markdown: string): { metadata: Metadata; content: string } {
const match = markdown.match(/^---\n([\s\S]+?)\n---/);
if (!match) throw new Error("Metadata not found");
const metadata = parse(match[1]) as Metadata;
const content = markdown.replace(match[0], "").trim();
return { metadata, content };
}
// 指定フォルダ内のファイルを取得
async function getMarkdownFiles(): Promise<string[]> {
const files: string[] = [];
for (const folder of uploadFolders) {
try {
for await (const file of Deno.readDir(`./content/${folder}`)) {
if (file.name.endsWith(".md")) {
files.push(`./content/${folder}/${file.name}`);
}
}
} catch (error) {
console.warn(`Skipping ${folder}: ${(error as Error).message}`);
}
}
return files;
}
// 記事のアップロード処理
async function uploadArticles() {
const markdownFiles = await getMarkdownFiles();
for (const filePath of markdownFiles) {
const fileName = filePath.split("/").pop()!;
const slug = fileName.replace(".md", "");
try {
const markdown = await Deno.readTextFile(filePath);
const { metadata, content } = parseMarkdown(markdown);
// `private: true` の記事はスキップ
if (metadata.private) {
console.log(`Skipping private article: ${slug}`);
continue;
}
console.log(`Uploading article: ${slug}`);
const { error } = await supabase.from("articles").upsert([
{
slug,
title: slug.replace(/-/g, " "),
content,
created_at: metadata.created_at,
updated_at: metadata.updated_at,
private: metadata.private,
aliases: metadata.aliases,
url: metadata.url,
cover_image: metadata.cover_image || null,
}
]);
if (error) console.error(`Error uploading ${slug}:`, error.message);
} catch (err) {
console.error(`Error processing file ${filePath}:`, (err as Error).message);
}
}
}
// 実行
await uploadArticles();
一回ローカルで実行してみる。.env
を作成して .gitignore
に含めておく。(.deno
内ではなく外に置いておく)
SUPABASE_URL=https://your-supabase-instance.supabase.co
SUPABASE_KEY=your-service-role-key
deno run --allow-read --allow-net --allow-env .deno/upload_to_supabase.ts
次のようなエラーが出た。
Uploading article: 2018-end Error uploading 2018-end: new row violates row-level security policy for table "articles"
RLSの設定をしているかららしい。SUPABASE_KEY
に設定する値を管理者キーに変更する。
アップロードできた。330記事あるらしい。
2回目に実行すると次のようになる。
Error uploading vvv-3-2-wordpress: duplicate key value violates unique constraint "articles_slug_key"
同じ slug
がある場合には上書きするようにする。
const { error } = await supabase.from("articles").upsert([
{
slug,
title: title,
content,
created_at: metadata.createdDate,
updated_at: metadata.updatedDate,
private: metadata.private,
aliases: metadata.aliases,
url: metadata.url,
cover_image: coverImagePath,
}
], { onConflict: ["slug"] }); // ← onConflict を追加
画像をアップロードする
まだSupabase側で何もやってないからか、Bucket not foundになった。
作った。
- public と private が選べた
- Fresh側でよしなにするのでprivateにした。
private
の場合は特殊でSignedURLを取得して、重複がないかを確認しないといけない。
async function getSignedUrl(fileName: string): Promise<string | null> {
const safeFileName = slugifyFileName(fileName);
const { data, error } = await supabase.storage.from(bucketName).createSignedUrl(safeFileName, 60 * 60);
if (error) {
console.error(`Error generating signed URL for ${safeFileName}:`, (error as Error).message);
return null;
}
return data.signedUrl;
}
Error uploading image: 2015-11-このブログの制作手順とかについてザックリと-9b59f04635264ca7b01b07d4cc3d8d5a.jpg: Invalid key: 2015-11-このブログの制作手順とかについてザックリと-9b59f04635264ca7b01b07d4cc3d8d5a.jpg
特殊文字や日本語が入っているとだめらしい。
ファイル名をASCIIにする関数を追加。
function slugifyFileName(fileName: string): string {
return fileName
.normalize("NFKD") // Unicode 正規化(濁点・半濁点を分離)
.replace(/[\u0300-\u036f]/g, "") // ダイアクリティカルマーク除去
.replace(/[^\w.-]/g, "_") // 許可されていない文字を `_` に変換
.replace(/_{2,}/g, "_") // 連続する `_` を1つに
.toLowerCase();
}
GitHub Actionsの設定
ここまでのコード
import "https://deno.land/std@0.224.0/dotenv/load.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js";
import { parse } from "https://deno.land/std/yaml/mod.ts";
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const SUPABASE_KEY = Deno.env.get("SUPABASE_KEY")!;
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
// アップロード対象のフォルダ(Markdown と画像)
const markdownFolders = ["010_Blog"];
const imageFolders = ["011_BlogImages"];
const imageExtensions = [".jpg", ".jpeg", ".png", ".webp", ".gif"];
const bucketName = "blog-images"; // 非公開バケット名
// Obsidian の画像埋め込み `![[filename|widthxheight]]`
const obsidianImageRegex = /!\[\[(.*?)\|?(\d*x\d*)?\]\]/g;
// リンク内の画像 `[](link)`
const nestedImageRegex = /\[\!\[\]\((.*?)\)\]\(.*?\)/g;
interface Metadata {
title?: string;
slug?: string;
createdDate: string;
updatedDate: string;
private: boolean;
aliases: string[];
url: string;
coverImage?: string;
}
// Markdown のメタデータと内容をパース
function parseMarkdown(
markdown: string,
): { metadata: Metadata; content: string } {
const match = markdown.match(/^---\n([\s\S]+?)\n---/);
if (!match) throw new Error("Metadata not found");
const metadata = parse(match[1]) as Metadata;
const content = markdown.replace(match[0], "").trim();
return { metadata, content };
}
// `signed URL` を取得する関数
async function getSignedUrl(fileName: string): Promise<string | null> {
const safeFileName = slugifyFileName(fileName);
const { data, error } = await supabase.storage.from(bucketName).createSignedUrl(safeFileName, 60 * 60);
if (error) {
console.error(`Error generating signed URL for ${safeFileName}:`, (error as Error).message);
return null;
}
return data.signedUrl;
}
// ファイル名を slugify して ASCII のみの形式に変換
function slugifyFileName(fileName: string): string {
return fileName
.normalize("NFKD") // Unicode 正規化(濁点・半濁点を分離)
.replace(/[\u0300-\u036f]/g, "") // ダイアクリティカルマーク除去
.replace(/[^\w.-]/g, "_") // 許可されていない文字を `_` に変換
.replace(/_{2,}/g, "_") // 連続する `_` を1つに
.toLowerCase();
}
// 画像を Supabase Storage にアップロード(変更があった場合のみ)
async function uploadImageToSupabase(filePath: string): Promise<string | null> {
const fileName = filePath.split("/").pop();
if (!fileName) {
console.error(`Invalid file name from ${filePath}`);
return null;
}
// ASCII 形式に変換
const safeFileName = slugifyFileName(fileName);
try {
const signedUrl = await getSignedUrl(safeFileName);
if (signedUrl) {
console.log(`Skipping unchanged image: ${fileName}`);
return signedUrl;
}
const fileData = await Deno.readFile(filePath);
const { error } = await supabase.storage.from(bucketName).upload(safeFileName, fileData, {
upsert: true,
contentType: getContentType(filePath),
});
if (error) {
console.error(`Error uploading image: ${fileName}:`, error.message);
return null;
}
console.log(`Uploaded image: ${fileName}`);
return await getSignedUrl(safeFileName);
} catch (error) {
console.error(`Error reading file ${filePath}:`, (error as Error).message);
return null;
}
}
// 画像のコンテンツタイプを取得
function getContentType(filePath: string): string {
const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
switch (ext) {
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".png":
return "image/png";
case ".webp":
return "image/webp";
case ".gif":
return "image/gif";
default:
return "application/octet-stream";
}
}
// 画像を処理する関数
async function processImage(imageUrl: string): Promise<string | null> {
const fileName = imageUrl.split("/").pop();
if (!fileName) {
console.error(`Invalid file name extracted from ${imageUrl}`);
return null;
}
for (const folder of imageFolders) {
const imagePath = `./${folder}/${fileName}`; // 既存の拡張子をそのまま利用
try {
await Deno.stat(imagePath);
console.log(`Processing image: ${imagePath}`);
return await uploadImageToSupabase(imagePath);
} catch {
continue;
}
}
console.error(`File not found for ${imageUrl}`);
return null;
}
// 指定フォルダ内の Markdown ファイルを取得
async function getMarkdownFiles(): Promise<string[]> {
const files: string[] = [];
for (const folder of markdownFolders) {
try {
for await (const file of Deno.readDir(`./${folder}`)) {
if (file.name.endsWith(".md")) {
files.push(`./${folder}/${file.name}`);
}
}
} catch (error) {
console.warn(`Skipping ${folder}: ${(error as Error).message}`);
}
}
return files;
}
// 記事のアップロード処理
async function uploadArticles() {
const markdownFiles = await getMarkdownFiles();
for (const filePath of markdownFiles) {
try {
const markdown = await Deno.readTextFile(filePath);
const { metadata, content } = parseMarkdown(markdown);
const fileName = filePath.split("/").pop()!;
const slug = metadata.slug ?? fileName.replace(".md", "");
const title = metadata.title ?? slug.replace(/-/g, " ");
// `private: true` の記事はスキップ
if (metadata.private) {
console.log(`Skipping private article: ${slug}`);
continue;
}
// coverImage の signed URL を取得
const coverImagePath = metadata.coverImage
? await processImage(metadata.coverImage)
: null;
// Obsidian 形式の画像を処理
let match;
while ((match = obsidianImageRegex.exec(content)) !== null) {
const imageUrl = match[1];
await processImage(imageUrl);
}
// リンク内の画像を処理
while ((match = nestedImageRegex.exec(content)) !== null) {
const imageUrl = match[1];
await processImage(imageUrl);
}
console.log(`Uploading article: ${slug}`);
const data = {
slug,
title: title,
content, // content は変更せずそのまま保存
created_at: metadata.createdDate,
updated_at: metadata.updatedDate,
private: metadata.private,
aliases: metadata.aliases,
url: metadata.url,
cover_image: coverImagePath, // signed URL を保存
};
const { error } = await supabase.from("articles").upsert([data], { onConflict: ["slug"] });
if (error) {
console.error(`Error uploading ${slug}:`, (error as Error).message);
}
} catch (err) {
console.error(
`Error processing file ${filePath}:`,
(err as Error).message,
);
}
}
}
// 実行
await uploadArticles();
name: Upload to Supabase
on:
push:
branches:
- main # `main` ブランチに push された時に実行
jobs:
upload:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Deno
uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- name: Run upload script
env:
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_KEY: ${{ secrets.SUPABASE_KEY }}
run: |
deno run --allow-read --allow-net --allow-env .deno/upload_to_supabase.ts
問題点
- mainブランチに変更があった時に全ての画像とノートをアップロードし始める
- 変更があったものだけで良い
- git diff で変更のあったファイルのみを抽出する
- めっちゃ苦戦した。
修正後に次のようなエラーが出た。
fatal: ambiguous argument 'HEAD^': unknown revision or path not in the working tree. Use '--' to separate paths from revisions, like this:
GitHub Actions で git diff HEAD^ HEAD を実行した際に HEAD^ が見つからない ことが原因。GitHub Actions の checkout はデフォルトで 1 つのコミットのみを取得するため、履歴がない。
最終コード
import "https://deno.land/std@0.224.0/dotenv/load.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js";
import { parse } from "https://deno.land/std/yaml/mod.ts";
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const SUPABASE_KEY = Deno.env.get("SUPABASE_KEY")!;
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
// アップロード対象のフォルダ(Markdown と画像)
const markdownFolders = ["010_Blog"];
const imageFolders = ["011_BlogImages"];
const bucketName = "blog-images"; // 非公開バケット名
// Obsidian の画像埋め込み `![[filename|widthxheight]]`
const obsidianImageRegex = /!\[\[(.*?)\|?(\d*x\d*)?\]\]/g;
// リンク内の画像 `[](link)`
const nestedImageRegex = /\[\!\[\]\((.*?)\)\]\(.*?\)/g;
interface Metadata {
title?: string;
slug?: string;
createdDate: string;
updatedDate: string;
private: boolean;
aliases: string[];
url: string;
coverImage?: string;
}
// 変更されたファイルを `changed_files.txt` から取得
async function getChangedFiles(): Promise<string[]> {
try {
const fileList = await Deno.readTextFile("changed_files.txt");
return fileList.trim()
.split("\n")
// deno-lint-ignore no-control-regex
.map((file) => file.replace(/\x00/g, "").trim()); // 🔥 NULL文字 (\x00) を削除
} catch (error) {
console.error("Error reading changed_files.txt:", (error as Error).message);
return [];
}
}
// **ファイルが指定フォルダに属しているかチェック**
function isValidFilePath(filePath: string, validFolders: string[]): boolean {
return validFolders.some((folder) => filePath.startsWith(folder + "/"));
}
// Markdown のメタデータと内容をパース
function parseMarkdown(
markdown: string,
): { metadata: Metadata; content: string } {
const match = markdown.match(/^---\n([\s\S]+?)\n---/);
if (!match) throw new Error("Metadata not found");
const metadata = parse(match[1]) as Metadata;
const content = markdown.replace(match[0], "").trim();
return { metadata, content };
}
// `signed URL` を取得する関数
async function getSignedUrl(fileName: string): Promise<string | null> {
const safeFileName = slugifyFileName(fileName);
const { data, error } = await supabase.storage.from(bucketName)
.createSignedUrl(safeFileName, 60 * 60);
if (error) {
console.error(
`Error generating signed URL for ${safeFileName}:`,
(error as Error).message,
);
return null;
}
return data.signedUrl;
}
// ファイル名を slugify して ASCII のみの形式に変換
function slugifyFileName(fileName: string): string {
return fileName
.normalize("NFKD") // Unicode 正規化(濁点・半濁点を分離)
.replace(/[\u0300-\u036f]/g, "") // ダイアクリティカルマーク除去
.replace(/[^\w.-]/g, "_") // 許可されていない文字を `_` に変換
.replace(/_{2,}/g, "_") // 連続する `_` を1つに
.toLowerCase();
}
// 画像を Supabase Storage にアップロード(変更があった場合のみ)
async function uploadImageToSupabase(filePath: string): Promise<string | null> {
const fileName = filePath.split("/").pop();
if (!fileName) {
console.error(`Invalid file name from ${filePath}`);
return null;
}
// ASCII 形式に変換
const safeFileName = slugifyFileName(fileName);
try {
const signedUrl = await getSignedUrl(safeFileName);
if (signedUrl) {
console.log(`Skipping unchanged image: ${fileName}`);
return signedUrl;
}
const fileData = await Deno.readFile(filePath);
const { error } = await supabase.storage.from(bucketName).upload(
safeFileName,
fileData,
{
upsert: true,
contentType: getContentType(filePath),
},
);
if (error) {
console.error(`Error uploading image: ${fileName}:`, error.message);
return null;
}
console.log(`Uploaded image: ${fileName}`);
return await getSignedUrl(safeFileName);
} catch (error) {
console.error(`Error reading file ${filePath}:`, (error as Error).message);
return null;
}
}
// 画像のコンテンツタイプを取得
function getContentType(filePath: string): string {
const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
switch (ext) {
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".png":
return "image/png";
case ".webp":
return "image/webp";
case ".gif":
return "image/gif";
default:
return "application/octet-stream";
}
}
// 画像を処理する関数
async function processImage(imageUrl: string): Promise<string | null> {
const fileName = imageUrl.split("/").pop();
if (!fileName) {
console.error(`Invalid file name extracted from ${imageUrl}`);
return null;
}
for (const folder of imageFolders) {
const imagePath = `./${folder}/${fileName}`; // 既存の拡張子をそのまま利用
try {
await Deno.stat(imagePath);
console.log(`Processing image: ${imagePath}`);
return await uploadImageToSupabase(imagePath);
} catch {
continue;
}
}
console.error(`File not found for ${imageUrl}`);
return null;
}
// 記事が Supabase に存在するか確認する関数
async function checkIfArticleExists(slug: string): Promise<boolean> {
const { data, error } = await supabase
.from("articles")
.select("slug")
.eq("slug", slug)
.single();
if (error) {
return false; // 記事が存在しない
}
return !!data;
}
// 記事のアップロード処理
async function uploadArticle(filePath: string) {
try {
const markdown = await Deno.readTextFile(filePath);
const { metadata, content } = parseMarkdown(markdown);
const fileName = filePath.split("/").pop()!;
const slug = metadata.slug ?? fileName.replace(".md", "");
const title = metadata.title ?? slug.replace(/-/g, " ");
if (!isValidFilePath(filePath, markdownFolders)) {
console.warn(`Skipping markdown outside valid folders: ${filePath}`);
return;
}
// `private: true` の場合の処理
if (metadata.private) {
const exists = await checkIfArticleExists(slug);
if (exists) {
console.log(`Updating existing article as private: ${slug}`);
const { error } = await supabase
.from("articles")
.update({ private: true })
.eq("slug", slug);
if (error) {
console.error(
`Error updating article as private: ${slug}`,
error.message,
);
}
} else {
console.log(`Skipping new private article: ${slug}`);
}
return;
}
// coverImage の signed URL を取得
const coverImagePath = metadata.coverImage
? await processImage(metadata.coverImage)
: null;
// Obsidian 形式の画像を処理
let match;
while ((match = obsidianImageRegex.exec(content)) !== null) {
const imageUrl = match[1];
await processImage(imageUrl);
}
// リンク内の画像を処理
while ((match = nestedImageRegex.exec(content)) !== null) {
const imageUrl = match[1];
await processImage(imageUrl);
}
console.log(`Uploading article: ${slug}`);
const data = {
slug,
title: title,
content, // content は変更せずそのまま保存
created_at: metadata.createdDate,
updated_at: metadata.updatedDate,
private: metadata.private,
aliases: metadata.aliases,
url: metadata.url,
cover_image: coverImagePath, // signed URL を保存
};
const { error } = await supabase.from("articles").upsert([data], {
onConflict: ["slug"],
});
if (error) {
console.error(`Error uploading ${slug}:`, (error as Error).message);
}
} catch (err) {
console.error(
`Error processing file ${filePath}:`,
(err as Error).message,
);
}
}
// 変更されたファイルを処理する関数
async function uploadChangedFiles() {
// 変更されたファイルのみを受け取る
const changedFiles = await getChangedFiles();
console.log("changedFiles:", changedFiles);
for (const filePath of changedFiles) {
if (filePath.endsWith(".md")) {
await uploadArticle(filePath);
} else if (/\.(jpg|jpeg|png|webp|gif)$/.test(filePath)) {
if (!isValidFilePath(filePath, imageFolders)) {
await processImage(filePath);
}
}
}
}
// 実行
await uploadChangedFiles();
name: Upload to Supabase
on:
push:
branches:
- main
jobs:
upload:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 2 # 最新の2コミットを取得して git diff が使えるようにする
- name: Get changed files
id: changed-files
run: |
if git rev-parse HEAD~1 >/dev/null 2>&1; then
CHANGED_FILES=$(git diff --name-only -z --diff-filter=AM HEAD~1 HEAD | tr '\0' '\n' | grep -E '\.(md|jpg|jpeg|png|webp|gif)$' || true)
else
CHANGED_FILES=$(git diff --name-only -z --diff-filter=AM HEAD | tr '\0' '\n' | grep -E '\.(md|jpg|jpeg|png|webp|gif)$' || true)
fi
if [[ -n "$CHANGED_FILES" ]]; then
{
echo "CHANGED_FILES<<EOF"
echo "$CHANGED_FILES"
echo "EOF"
} >> "$GITHUB_ENV"
else
echo "CHANGED_FILES=none" >> "$GITHUB_ENV"
fi
- name: Install Deno
if: env.CHANGED_FILES != 'none'
uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- name: Upload changed files to Supabase
if: env.CHANGED_FILES != 'none'
env:
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_KEY: ${{ secrets.SUPABASE_KEY }}
CHANGED_FILES: ${{ env.CHANGED_FILES }}
run: |
echo -e "$CHANGED_FILES" > changed_files.txt
deno run --allow-read --allow-net --allow-env .deno/upload_to_supabase.ts changed_files.txt
